/*
 * Copyright (C) 2010 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License
 */

package com.android.contacts.common.model;

import android.content.AsyncTaskLoader;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.res.AssetFileDescriptor;
import android.content.res.Resources;
import android.database.Cursor;
import android.net.Uri;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
import android.provider.ContactsContract.Contacts;
import android.provider.ContactsContract.Data;
import android.provider.ContactsContract.Directory;
import android.provider.ContactsContract.Groups;
import android.provider.ContactsContract.RawContacts;
import android.text.TextUtils;
import com.android.contacts.common.GroupMetaData;
import com.android.contacts.common.model.account.AccountType;
import com.android.contacts.common.model.account.AccountTypeWithDataSet;
import com.android.contacts.common.model.account.GoogleAccountType;
import com.android.contacts.common.model.dataitem.DataItem;
import com.android.contacts.common.model.dataitem.PhoneDataItem;
import com.android.contacts.common.model.dataitem.PhotoDataItem;
import com.android.contacts.common.util.Constants;
import com.android.contacts.common.util.ContactLoaderUtils;
import com.android.dialer.common.LogUtil;
import com.android.dialer.location.GeoUtil;
import com.android.dialer.util.PermissionsUtil;
import com.android.dialer.util.UriUtils;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

/** Loads a single Contact and all it constituent RawContacts. */
public class ContactLoader extends AsyncTaskLoader<Contact> {

  private static final String TAG = ContactLoader.class.getSimpleName();

  /** A short-lived cache that can be set by {@link #cacheResult()} */
  private static Contact sCachedResult = null;

  private final Uri mRequestedUri;
  private final Set<Long> mNotifiedRawContactIds = Sets.newHashSet();
  private Uri mLookupUri;
  private boolean mLoadGroupMetaData;
  private boolean mLoadInvitableAccountTypes;
  private boolean mPostViewNotification;
  private boolean mComputeFormattedPhoneNumber;
  private Contact mContact;
  private ForceLoadContentObserver mObserver;

  public ContactLoader(Context context, Uri lookupUri, boolean postViewNotification) {
    this(context, lookupUri, false, false, postViewNotification, false);
  }

  public ContactLoader(
      Context context,
      Uri lookupUri,
      boolean loadGroupMetaData,
      boolean loadInvitableAccountTypes,
      boolean postViewNotification,
      boolean computeFormattedPhoneNumber) {
    super(context);
    mLookupUri = lookupUri;
    mRequestedUri = lookupUri;
    mLoadGroupMetaData = loadGroupMetaData;
    mLoadInvitableAccountTypes = loadInvitableAccountTypes;
    mPostViewNotification = postViewNotification;
    mComputeFormattedPhoneNumber = computeFormattedPhoneNumber;
  }

  /**
   * Parses a {@link Contact} stored as a JSON string in a lookup URI.
   *
   * @param lookupUri The contact information to parse .
   * @return The parsed {@code Contact} information.
   */
  public static Contact parseEncodedContactEntity(Uri lookupUri) {
    try {
      return loadEncodedContactEntity(lookupUri, lookupUri);
    } catch (JSONException je) {
      return null;
    }
  }

  private static Contact loadEncodedContactEntity(Uri uri, Uri lookupUri) throws JSONException {
    final String jsonString = uri.getEncodedFragment();
    final JSONObject json = new JSONObject(jsonString);

    final long directoryId =
        Long.valueOf(uri.getQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY));

    final String displayName = json.optString(Contacts.DISPLAY_NAME);
    final String altDisplayName = json.optString(Contacts.DISPLAY_NAME_ALTERNATIVE, displayName);
    final int displayNameSource = json.getInt(Contacts.DISPLAY_NAME_SOURCE);
    final String photoUri = json.optString(Contacts.PHOTO_URI, null);
    final Contact contact =
        new Contact(
            uri,
            uri,
            lookupUri,
            directoryId,
            null /* lookupKey */,
            -1 /* id */,
            -1 /* nameRawContactId */,
            displayNameSource,
            0 /* photoId */,
            photoUri,
            displayName,
            altDisplayName,
            null /* phoneticName */,
            false /* starred */,
            null /* presence */,
            false /* sendToVoicemail */,
            null /* customRingtone */,
            false /* isUserProfile */);

    final String accountName = json.optString(RawContacts.ACCOUNT_NAME, null);
    final String directoryName = uri.getQueryParameter(Directory.DISPLAY_NAME);
    if (accountName != null) {
      final String accountType = json.getString(RawContacts.ACCOUNT_TYPE);
      contact.setDirectoryMetaData(
          directoryName,
          null,
          accountName,
          accountType,
          json.optInt(Directory.EXPORT_SUPPORT, Directory.EXPORT_SUPPORT_SAME_ACCOUNT_ONLY));
    } else {
      contact.setDirectoryMetaData(
          directoryName,
          null,
          null,
          null,
          json.optInt(Directory.EXPORT_SUPPORT, Directory.EXPORT_SUPPORT_ANY_ACCOUNT));
    }

    final ContentValues values = new ContentValues();
    values.put(Data._ID, -1);
    values.put(Data.CONTACT_ID, -1);
    final RawContact rawContact = new RawContact(values);

    final JSONObject items = json.getJSONObject(Contacts.CONTENT_ITEM_TYPE);
    final Iterator keys = items.keys();
    while (keys.hasNext()) {
      final String mimetype = (String) keys.next();

      // Could be single object or array.
      final JSONObject obj = items.optJSONObject(mimetype);
      if (obj == null) {
        final JSONArray array = items.getJSONArray(mimetype);
        for (int i = 0; i < array.length(); i++) {
          final JSONObject item = array.getJSONObject(i);
          processOneRecord(rawContact, item, mimetype);
        }
      } else {
        processOneRecord(rawContact, obj, mimetype);
      }
    }

    contact.setRawContacts(new ImmutableList.Builder<RawContact>().add(rawContact).build());
    return contact;
  }

  private static void processOneRecord(RawContact rawContact, JSONObject item, String mimetype)
      throws JSONException {
    final ContentValues itemValues = new ContentValues();
    itemValues.put(Data.MIMETYPE, mimetype);
    itemValues.put(Data._ID, -1);

    final Iterator iterator = item.keys();
    while (iterator.hasNext()) {
      String name = (String) iterator.next();
      final Object o = item.get(name);
      if (o instanceof String) {
        itemValues.put(name, (String) o);
      } else if (o instanceof Integer) {
        itemValues.put(name, (Integer) o);
      }
    }
    rawContact.addDataItemValues(itemValues);
  }

  @Override
  public Contact loadInBackground() {
    LogUtil.v(TAG, "loadInBackground=" + mLookupUri);
    try {
      final ContentResolver resolver = getContext().getContentResolver();
      final Uri uriCurrentFormat = ContactLoaderUtils.ensureIsContactUri(resolver, mLookupUri);
      final Contact cachedResult = sCachedResult;
      sCachedResult = null;
      // Is this the same Uri as what we had before already? In that case, reuse that result
      final Contact result;
      final boolean resultIsCached;
      if (cachedResult != null && UriUtils.areEqual(cachedResult.getLookupUri(), mLookupUri)) {
        // We are using a cached result from earlier. Below, we should make sure
        // we are not doing any more network or disc accesses
        result = new Contact(mRequestedUri, cachedResult);
        resultIsCached = true;
      } else {
        if (uriCurrentFormat.getLastPathSegment().equals(Constants.LOOKUP_URI_ENCODED)) {
          result = loadEncodedContactEntity(uriCurrentFormat, mLookupUri);
        } else {
          result = loadContactEntity(resolver, uriCurrentFormat);
        }
        resultIsCached = false;
      }
      if (result.isLoaded()) {
        if (result.isDirectoryEntry()) {
          if (!resultIsCached) {
            loadDirectoryMetaData(result);
          }
        } else if (mLoadGroupMetaData) {
          if (result.getGroupMetaData() == null) {
            loadGroupMetaData(result);
          }
        }
        if (mComputeFormattedPhoneNumber) {
          computeFormattedPhoneNumbers(result);
        }
        if (!resultIsCached) {
          loadPhotoBinaryData(result);
        }

        // Note ME profile should never have "Add connection"
        if (mLoadInvitableAccountTypes && result.getInvitableAccountTypes() == null) {
          loadInvitableAccountTypes(result);
        }
      }
      return result;
    } catch (Exception e) {
      LogUtil.e(TAG, "Error loading the contact: " + mLookupUri, e);
      return Contact.forError(mRequestedUri, e);
    }
  }

  private Contact loadContactEntity(ContentResolver resolver, Uri contactUri) {
    Uri entityUri = Uri.withAppendedPath(contactUri, Contacts.Entity.CONTENT_DIRECTORY);
    Cursor cursor =
        resolver.query(entityUri, ContactQuery.COLUMNS, null, null, Contacts.Entity.RAW_CONTACT_ID);
    if (cursor == null) {
      LogUtil.e(TAG, "No cursor returned in loadContactEntity");
      return Contact.forNotFound(mRequestedUri);
    }

    try {
      if (!cursor.moveToFirst()) {
        cursor.close();
        return Contact.forNotFound(mRequestedUri);
      }

      // Create the loaded contact starting with the header data.
      Contact contact = loadContactHeaderData(cursor, contactUri);

      // Fill in the raw contacts, which is wrapped in an Entity and any
      // status data.  Initially, result has empty entities and statuses.
      long currentRawContactId = -1;
      RawContact rawContact = null;
      ImmutableList.Builder<RawContact> rawContactsBuilder =
          new ImmutableList.Builder<RawContact>();
      do {
        long rawContactId = cursor.getLong(ContactQuery.RAW_CONTACT_ID);
        if (rawContactId != currentRawContactId) {
          // First time to see this raw contact id, so create a new entity, and
          // add it to the result's entities.
          currentRawContactId = rawContactId;
          rawContact = new RawContact(loadRawContactValues(cursor));
          rawContactsBuilder.add(rawContact);
        }
        if (!cursor.isNull(ContactQuery.DATA_ID)) {
          ContentValues data = loadDataValues(cursor);
          rawContact.addDataItemValues(data);
        }
      } while (cursor.moveToNext());

      contact.setRawContacts(rawContactsBuilder.build());

      return contact;
    } finally {
      cursor.close();
    }
  }

  /**
   * Looks for the photo data item in entities. If found, a thumbnail will be stored. A larger photo
   * will also be stored if available.
   */
  private void loadPhotoBinaryData(Contact contactData) {
    loadThumbnailBinaryData(contactData);

    // Try to load the large photo from a file using the photo URI.
    String photoUri = contactData.getPhotoUri();
    if (photoUri != null) {
      try {
        final InputStream inputStream;
        final AssetFileDescriptor fd;
        final Uri uri = Uri.parse(photoUri);
        final String scheme = uri.getScheme();
        if ("http".equals(scheme) || "https".equals(scheme)) {
          // Support HTTP urls that might come from extended directories
          inputStream = new URL(photoUri).openStream();
          fd = null;
        } else {
          fd = getContext().getContentResolver().openAssetFileDescriptor(uri, "r");
          inputStream = fd.createInputStream();
        }
        byte[] buffer = new byte[16 * 1024];
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try {
          int size;
          while ((size = inputStream.read(buffer)) != -1) {
            baos.write(buffer, 0, size);
          }
          contactData.setPhotoBinaryData(baos.toByteArray());
        } finally {
          inputStream.close();
          if (fd != null) {
            fd.close();
          }
        }
        return;
      } catch (IOException ioe) {
        // Just fall back to the case below.
      }
    }

    // If we couldn't load from a file, fall back to the data blob.
    contactData.setPhotoBinaryData(contactData.getThumbnailPhotoBinaryData());
  }

  private void loadThumbnailBinaryData(Contact contactData) {
    final long photoId = contactData.getPhotoId();
    if (photoId <= 0) {
      // No photo ID
      return;
    }

    for (RawContact rawContact : contactData.getRawContacts()) {
      for (DataItem dataItem : rawContact.getDataItems()) {
        if (dataItem.getId() == photoId) {
          if (!(dataItem instanceof PhotoDataItem)) {
            break;
          }

          final PhotoDataItem photo = (PhotoDataItem) dataItem;
          contactData.setThumbnailPhotoBinaryData(photo.getPhoto());
          break;
        }
      }
    }
  }

  /** Sets the "invitable" account types to {@link Contact#mInvitableAccountTypes}. */
  private void loadInvitableAccountTypes(Contact contactData) {
    final ImmutableList.Builder<AccountType> resultListBuilder =
        new ImmutableList.Builder<AccountType>();
    if (!contactData.isUserProfile()) {
      Map<AccountTypeWithDataSet, AccountType> invitables =
          AccountTypeManager.getInstance(getContext()).getUsableInvitableAccountTypes();
      if (!invitables.isEmpty()) {
        final Map<AccountTypeWithDataSet, AccountType> resultMap = Maps.newHashMap(invitables);

        // Remove the ones that already have a raw contact in the current contact
        for (RawContact rawContact : contactData.getRawContacts()) {
          final AccountTypeWithDataSet type =
              AccountTypeWithDataSet.get(
                  rawContact.getAccountTypeString(), rawContact.getDataSet());
          resultMap.remove(type);
        }

        resultListBuilder.addAll(resultMap.values());
      }
    }

    // Set to mInvitableAccountTypes
    contactData.setInvitableAccountTypes(resultListBuilder.build());
  }

  /** Extracts Contact level columns from the cursor. */
  private Contact loadContactHeaderData(final Cursor cursor, Uri contactUri) {
    final String directoryParameter =
        contactUri.getQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY);
    final long directoryId =
        directoryParameter == null ? Directory.DEFAULT : Long.parseLong(directoryParameter);
    final long contactId = cursor.getLong(ContactQuery.CONTACT_ID);
    final String lookupKey = cursor.getString(ContactQuery.LOOKUP_KEY);
    final long nameRawContactId = cursor.getLong(ContactQuery.NAME_RAW_CONTACT_ID);
    final int displayNameSource = cursor.getInt(ContactQuery.DISPLAY_NAME_SOURCE);
    final String displayName = cursor.getString(ContactQuery.DISPLAY_NAME);
    final String altDisplayName = cursor.getString(ContactQuery.ALT_DISPLAY_NAME);
    final String phoneticName = cursor.getString(ContactQuery.PHONETIC_NAME);
    final long photoId = cursor.getLong(ContactQuery.PHOTO_ID);
    final String photoUri = cursor.getString(ContactQuery.PHOTO_URI);
    final boolean starred = cursor.getInt(ContactQuery.STARRED) != 0;
    final Integer presence =
        cursor.isNull(ContactQuery.CONTACT_PRESENCE)
            ? null
            : cursor.getInt(ContactQuery.CONTACT_PRESENCE);
    final boolean sendToVoicemail = cursor.getInt(ContactQuery.SEND_TO_VOICEMAIL) == 1;
    final String customRingtone = cursor.getString(ContactQuery.CUSTOM_RINGTONE);
    final boolean isUserProfile = cursor.getInt(ContactQuery.IS_USER_PROFILE) == 1;

    Uri lookupUri;
    if (directoryId == Directory.DEFAULT || directoryId == Directory.LOCAL_INVISIBLE) {
      lookupUri =
          ContentUris.withAppendedId(
              Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, lookupKey), contactId);
    } else {
      lookupUri = contactUri;
    }

    return new Contact(
        mRequestedUri,
        contactUri,
        lookupUri,
        directoryId,
        lookupKey,
        contactId,
        nameRawContactId,
        displayNameSource,
        photoId,
        photoUri,
        displayName,
        altDisplayName,
        phoneticName,
        starred,
        presence,
        sendToVoicemail,
        customRingtone,
        isUserProfile);
  }

  /** Extracts RawContact level columns from the cursor. */
  private ContentValues loadRawContactValues(Cursor cursor) {
    ContentValues cv = new ContentValues();

    cv.put(RawContacts._ID, cursor.getLong(ContactQuery.RAW_CONTACT_ID));

    cursorColumnToContentValues(cursor, cv, ContactQuery.ACCOUNT_NAME);
    cursorColumnToContentValues(cursor, cv, ContactQuery.ACCOUNT_TYPE);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SET);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DIRTY);
    cursorColumnToContentValues(cursor, cv, ContactQuery.VERSION);
    cursorColumnToContentValues(cursor, cv, ContactQuery.SOURCE_ID);
    cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC1);
    cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC2);
    cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC3);
    cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC4);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DELETED);
    cursorColumnToContentValues(cursor, cv, ContactQuery.CONTACT_ID);
    cursorColumnToContentValues(cursor, cv, ContactQuery.STARRED);

    return cv;
  }

  /** Extracts Data level columns from the cursor. */
  private ContentValues loadDataValues(Cursor cursor) {
    ContentValues cv = new ContentValues();

    cv.put(Data._ID, cursor.getLong(ContactQuery.DATA_ID));

    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA1);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA2);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA3);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA4);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA5);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA6);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA7);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA8);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA9);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA10);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA11);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA12);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA13);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA14);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA15);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC1);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC2);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC3);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC4);
    cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_VERSION);
    cursorColumnToContentValues(cursor, cv, ContactQuery.IS_PRIMARY);
    cursorColumnToContentValues(cursor, cv, ContactQuery.IS_SUPERPRIMARY);
    cursorColumnToContentValues(cursor, cv, ContactQuery.MIMETYPE);
    cursorColumnToContentValues(cursor, cv, ContactQuery.GROUP_SOURCE_ID);
    cursorColumnToContentValues(cursor, cv, ContactQuery.CHAT_CAPABILITY);
    cursorColumnToContentValues(cursor, cv, ContactQuery.TIMES_USED);
    cursorColumnToContentValues(cursor, cv, ContactQuery.LAST_TIME_USED);
    cursorColumnToContentValues(cursor, cv, ContactQuery.CARRIER_PRESENCE);

    return cv;
  }

  private void cursorColumnToContentValues(Cursor cursor, ContentValues values, int index) {
    switch (cursor.getType(index)) {
      case Cursor.FIELD_TYPE_NULL:
        // don't put anything in the content values
        break;
      case Cursor.FIELD_TYPE_INTEGER:
        values.put(ContactQuery.COLUMNS[index], cursor.getLong(index));
        break;
      case Cursor.FIELD_TYPE_STRING:
        values.put(ContactQuery.COLUMNS[index], cursor.getString(index));
        break;
      case Cursor.FIELD_TYPE_BLOB:
        values.put(ContactQuery.COLUMNS[index], cursor.getBlob(index));
        break;
      default:
        throw new IllegalStateException("Invalid or unhandled data type");
    }
  }

  private void loadDirectoryMetaData(Contact result) {
    long directoryId = result.getDirectoryId();

    Cursor cursor =
        getContext()
            .getContentResolver()
            .query(
                ContentUris.withAppendedId(Directory.CONTENT_URI, directoryId),
                DirectoryQuery.COLUMNS,
                null,
                null,
                null);
    if (cursor == null) {
      return;
    }
    try {
      if (cursor.moveToFirst()) {
        final String displayName = cursor.getString(DirectoryQuery.DISPLAY_NAME);
        final String packageName = cursor.getString(DirectoryQuery.PACKAGE_NAME);
        final int typeResourceId = cursor.getInt(DirectoryQuery.TYPE_RESOURCE_ID);
        final String accountType = cursor.getString(DirectoryQuery.ACCOUNT_TYPE);
        final String accountName = cursor.getString(DirectoryQuery.ACCOUNT_NAME);
        final int exportSupport = cursor.getInt(DirectoryQuery.EXPORT_SUPPORT);
        String directoryType = null;
        if (!TextUtils.isEmpty(packageName)) {
          PackageManager pm = getContext().getPackageManager();
          try {
            Resources resources = pm.getResourcesForApplication(packageName);
            directoryType = resources.getString(typeResourceId);
          } catch (NameNotFoundException e) {
            LogUtil.w(
                TAG, "Contact directory resource not found: " + packageName + "." + typeResourceId);
          }
        }

        result.setDirectoryMetaData(
            displayName, directoryType, accountType, accountName, exportSupport);
      }
    } finally {
      cursor.close();
    }
  }

  /**
   * Loads groups meta-data for all groups associated with all constituent raw contacts' accounts.
   */
  private void loadGroupMetaData(Contact result) {
    StringBuilder selection = new StringBuilder();
    ArrayList<String> selectionArgs = new ArrayList<String>();
    final HashSet<AccountKey> accountsSeen = new HashSet<>();
    for (RawContact rawContact : result.getRawContacts()) {
      final String accountName = rawContact.getAccountName();
      final String accountType = rawContact.getAccountTypeString();
      final String dataSet = rawContact.getDataSet();
      final AccountKey accountKey = new AccountKey(accountName, accountType, dataSet);
      if (accountName != null && accountType != null && !accountsSeen.contains(accountKey)) {
        accountsSeen.add(accountKey);
        if (selection.length() != 0) {
          selection.append(" OR ");
        }
        selection.append("(" + Groups.ACCOUNT_NAME + "=? AND " + Groups.ACCOUNT_TYPE + "=?");
        selectionArgs.add(accountName);
        selectionArgs.add(accountType);

        if (dataSet != null) {
          selection.append(" AND " + Groups.DATA_SET + "=?");
          selectionArgs.add(dataSet);
        } else {
          selection.append(" AND " + Groups.DATA_SET + " IS NULL");
        }
        selection.append(")");
      }
    }
    final ImmutableList.Builder<GroupMetaData> groupListBuilder =
        new ImmutableList.Builder<GroupMetaData>();
    final Cursor cursor =
        getContext()
            .getContentResolver()
            .query(
                Groups.CONTENT_URI,
                GroupQuery.COLUMNS,
                selection.toString(),
                selectionArgs.toArray(new String[0]),
                null);
    if (cursor != null) {
      try {
        while (cursor.moveToNext()) {
          final String accountName = cursor.getString(GroupQuery.ACCOUNT_NAME);
          final String accountType = cursor.getString(GroupQuery.ACCOUNT_TYPE);
          final String dataSet = cursor.getString(GroupQuery.DATA_SET);
          final long groupId = cursor.getLong(GroupQuery.ID);
          final String title = cursor.getString(GroupQuery.TITLE);
          final boolean defaultGroup =
              !cursor.isNull(GroupQuery.AUTO_ADD) && cursor.getInt(GroupQuery.AUTO_ADD) != 0;
          final boolean favorites =
              !cursor.isNull(GroupQuery.FAVORITES) && cursor.getInt(GroupQuery.FAVORITES) != 0;

          groupListBuilder.add(
              new GroupMetaData(
                  accountName, accountType, dataSet, groupId, title, defaultGroup, favorites));
        }
      } finally {
        cursor.close();
      }
    }
    result.setGroupMetaData(groupListBuilder.build());
  }

  /**
   * Iterates over all data items that represent phone numbers are tries to calculate a formatted
   * number. This function can safely be called several times as no unformatted data is overwritten
   */
  private void computeFormattedPhoneNumbers(Contact contactData) {
    final String countryIso = GeoUtil.getCurrentCountryIso(getContext());
    final ImmutableList<RawContact> rawContacts = contactData.getRawContacts();
    final int rawContactCount = rawContacts.size();
    for (int rawContactIndex = 0; rawContactIndex < rawContactCount; rawContactIndex++) {
      final RawContact rawContact = rawContacts.get(rawContactIndex);
      final List<DataItem> dataItems = rawContact.getDataItems();
      final int dataCount = dataItems.size();
      for (int dataIndex = 0; dataIndex < dataCount; dataIndex++) {
        final DataItem dataItem = dataItems.get(dataIndex);
        if (dataItem instanceof PhoneDataItem) {
          final PhoneDataItem phoneDataItem = (PhoneDataItem) dataItem;
          phoneDataItem.computeFormattedPhoneNumber(getContext(), countryIso);
        }
      }
    }
  }

  @Override
  public void deliverResult(Contact result) {
    unregisterObserver();

    // The creator isn't interested in any further updates
    if (isReset() || result == null) {
      return;
    }

    mContact = result;

    if (result.isLoaded()) {
      mLookupUri = result.getLookupUri();

      if (!result.isDirectoryEntry()) {
        if (mObserver == null) {
          mObserver = new ForceLoadContentObserver();
        }

        if (PermissionsUtil.hasContactsReadPermissions(getContext())) {
          getContext().getContentResolver().registerContentObserver(mLookupUri, true, mObserver);
        } else {
          LogUtil.w("ContactLoader.deliverResult", "contacts permission not available");
        }
      }

      if (mPostViewNotification) {
        // inform the source of the data that this contact is being looked at
        postViewNotificationToSyncAdapter();
      }
    }

    super.deliverResult(mContact);
  }

  /**
   * Posts a message to the contributing sync adapters that have opted-in, notifying them that the
   * contact has just been loaded
   */
  private void postViewNotificationToSyncAdapter() {
    Context context = getContext();
    for (RawContact rawContact : mContact.getRawContacts()) {
      final long rawContactId = rawContact.getId();
      if (mNotifiedRawContactIds.contains(rawContactId)) {
        continue; // Already notified for this raw contact.
      }
      mNotifiedRawContactIds.add(rawContactId);
      final AccountType accountType = rawContact.getAccountType(context);
      final String serviceName = accountType.getViewContactNotifyServiceClassName();
      final String servicePackageName = accountType.getViewContactNotifyServicePackageName();
      if (!TextUtils.isEmpty(serviceName) && !TextUtils.isEmpty(servicePackageName)) {
        final Uri uri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId);
        if (accountType instanceof GoogleAccountType) {
          ((GoogleAccountType) accountType).handleRawContactViewed(context, uri);
          continue;
        }
        final Intent intent = new Intent();
        intent.setClassName(servicePackageName, serviceName);
        intent.setAction(Intent.ACTION_VIEW);
        intent.setDataAndType(uri, RawContacts.CONTENT_ITEM_TYPE);
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        try {
          context.startService(intent);
        } catch (Exception e) {
          LogUtil.e(TAG, "Error sending message to source-app", e);
        }
      }
    }
  }

  private void unregisterObserver() {
    if (mObserver != null) {
      getContext().getContentResolver().unregisterContentObserver(mObserver);
      mObserver = null;
    }
  }

  public Uri getLookupUri() {
    return mLookupUri;
  }

  public void setLookupUri(Uri lookupUri) {
    mLookupUri = lookupUri;
  }

  @Override
  protected void onStartLoading() {
    if (mContact != null) {
      deliverResult(mContact);
    }

    if (takeContentChanged() || mContact == null) {
      forceLoad();
    }
  }

  @Override
  protected void onStopLoading() {
    cancelLoad();
  }

  @Override
  protected void onReset() {
    super.onReset();
    cancelLoad();
    unregisterObserver();
    mContact = null;
  }

  /**
   * Projection used for the query that loads all data for the entire contact (except for social
   * stream items).
   */
  private static class ContactQuery {

    public static final int NAME_RAW_CONTACT_ID = 0;
    public static final int DISPLAY_NAME_SOURCE = 1;
    public static final int LOOKUP_KEY = 2;
    public static final int DISPLAY_NAME = 3;
    public static final int ALT_DISPLAY_NAME = 4;
    public static final int PHONETIC_NAME = 5;
    public static final int PHOTO_ID = 6;
    public static final int STARRED = 7;
    public static final int CONTACT_PRESENCE = 8;
    public static final int CONTACT_STATUS = 9;
    public static final int CONTACT_STATUS_TIMESTAMP = 10;
    public static final int CONTACT_STATUS_RES_PACKAGE = 11;
    public static final int CONTACT_STATUS_LABEL = 12;
    public static final int CONTACT_ID = 13;
    public static final int RAW_CONTACT_ID = 14;
    public static final int ACCOUNT_NAME = 15;
    public static final int ACCOUNT_TYPE = 16;
    public static final int DATA_SET = 17;
    public static final int DIRTY = 18;
    public static final int VERSION = 19;
    public static final int SOURCE_ID = 20;
    public static final int SYNC1 = 21;
    public static final int SYNC2 = 22;
    public static final int SYNC3 = 23;
    public static final int SYNC4 = 24;
    public static final int DELETED = 25;
    public static final int DATA_ID = 26;
    public static final int DATA1 = 27;
    public static final int DATA2 = 28;
    public static final int DATA3 = 29;
    public static final int DATA4 = 30;
    public static final int DATA5 = 31;
    public static final int DATA6 = 32;
    public static final int DATA7 = 33;
    public static final int DATA8 = 34;
    public static final int DATA9 = 35;
    public static final int DATA10 = 36;
    public static final int DATA11 = 37;
    public static final int DATA12 = 38;
    public static final int DATA13 = 39;
    public static final int DATA14 = 40;
    public static final int DATA15 = 41;
    public static final int DATA_SYNC1 = 42;
    public static final int DATA_SYNC2 = 43;
    public static final int DATA_SYNC3 = 44;
    public static final int DATA_SYNC4 = 45;
    public static final int DATA_VERSION = 46;
    public static final int IS_PRIMARY = 47;
    public static final int IS_SUPERPRIMARY = 48;
    public static final int MIMETYPE = 49;
    public static final int GROUP_SOURCE_ID = 50;
    public static final int PRESENCE = 51;
    public static final int CHAT_CAPABILITY = 52;
    public static final int STATUS = 53;
    public static final int STATUS_RES_PACKAGE = 54;
    public static final int STATUS_ICON = 55;
    public static final int STATUS_LABEL = 56;
    public static final int STATUS_TIMESTAMP = 57;
    public static final int PHOTO_URI = 58;
    public static final int SEND_TO_VOICEMAIL = 59;
    public static final int CUSTOM_RINGTONE = 60;
    public static final int IS_USER_PROFILE = 61;
    public static final int TIMES_USED = 62;
    public static final int LAST_TIME_USED = 63;
    public static final int CARRIER_PRESENCE = 64;
    static final String[] COLUMNS_INTERNAL =
        new String[] {
          Contacts.NAME_RAW_CONTACT_ID,
          Contacts.DISPLAY_NAME_SOURCE,
          Contacts.LOOKUP_KEY,
          Contacts.DISPLAY_NAME,
          Contacts.DISPLAY_NAME_ALTERNATIVE,
          Contacts.PHONETIC_NAME,
          Contacts.PHOTO_ID,
          Contacts.STARRED,
          Contacts.CONTACT_PRESENCE,
          Contacts.CONTACT_STATUS,
          Contacts.CONTACT_STATUS_TIMESTAMP,
          Contacts.CONTACT_STATUS_RES_PACKAGE,
          Contacts.CONTACT_STATUS_LABEL,
          Contacts.Entity.CONTACT_ID,
          Contacts.Entity.RAW_CONTACT_ID,
          RawContacts.ACCOUNT_NAME,
          RawContacts.ACCOUNT_TYPE,
          RawContacts.DATA_SET,
          RawContacts.DIRTY,
          RawContacts.VERSION,
          RawContacts.SOURCE_ID,
          RawContacts.SYNC1,
          RawContacts.SYNC2,
          RawContacts.SYNC3,
          RawContacts.SYNC4,
          RawContacts.DELETED,
          Contacts.Entity.DATA_ID,
          Data.DATA1,
          Data.DATA2,
          Data.DATA3,
          Data.DATA4,
          Data.DATA5,
          Data.DATA6,
          Data.DATA7,
          Data.DATA8,
          Data.DATA9,
          Data.DATA10,
          Data.DATA11,
          Data.DATA12,
          Data.DATA13,
          Data.DATA14,
          Data.DATA15,
          Data.SYNC1,
          Data.SYNC2,
          Data.SYNC3,
          Data.SYNC4,
          Data.DATA_VERSION,
          Data.IS_PRIMARY,
          Data.IS_SUPER_PRIMARY,
          Data.MIMETYPE,
          GroupMembership.GROUP_SOURCE_ID,
          Data.PRESENCE,
          Data.CHAT_CAPABILITY,
          Data.STATUS,
          Data.STATUS_RES_PACKAGE,
          Data.STATUS_ICON,
          Data.STATUS_LABEL,
          Data.STATUS_TIMESTAMP,
          Contacts.PHOTO_URI,
          Contacts.SEND_TO_VOICEMAIL,
          Contacts.CUSTOM_RINGTONE,
          Contacts.IS_USER_PROFILE,
          Data.TIMES_USED,
          Data.LAST_TIME_USED
        };
    static final String[] COLUMNS;

    static {
      List<String> projectionList = Lists.newArrayList(COLUMNS_INTERNAL);
      projectionList.add(Data.CARRIER_PRESENCE);
      COLUMNS = projectionList.toArray(new String[projectionList.size()]);
    }
  }

  /** Projection used for the query that loads all data for the entire contact. */
  private static class DirectoryQuery {

    public static final int DISPLAY_NAME = 0;
    public static final int PACKAGE_NAME = 1;
    public static final int TYPE_RESOURCE_ID = 2;
    public static final int ACCOUNT_TYPE = 3;
    public static final int ACCOUNT_NAME = 4;
    public static final int EXPORT_SUPPORT = 5;
    static final String[] COLUMNS =
        new String[] {
          Directory.DISPLAY_NAME,
          Directory.PACKAGE_NAME,
          Directory.TYPE_RESOURCE_ID,
          Directory.ACCOUNT_TYPE,
          Directory.ACCOUNT_NAME,
          Directory.EXPORT_SUPPORT,
        };
  }

  private static class GroupQuery {

    public static final int ACCOUNT_NAME = 0;
    public static final int ACCOUNT_TYPE = 1;
    public static final int DATA_SET = 2;
    public static final int ID = 3;
    public static final int TITLE = 4;
    public static final int AUTO_ADD = 5;
    public static final int FAVORITES = 6;
    static final String[] COLUMNS =
        new String[] {
          Groups.ACCOUNT_NAME,
          Groups.ACCOUNT_TYPE,
          Groups.DATA_SET,
          Groups._ID,
          Groups.TITLE,
          Groups.AUTO_ADD,
          Groups.FAVORITES,
        };
  }

  private static class AccountKey {

    private final String mAccountName;
    private final String mAccountType;
    private final String mDataSet;

    public AccountKey(String accountName, String accountType, String dataSet) {
      mAccountName = accountName;
      mAccountType = accountType;
      mDataSet = dataSet;
    }

    @Override
    public int hashCode() {
      return Objects.hash(mAccountName, mAccountType, mDataSet);
    }

    @Override
    public boolean equals(Object obj) {
      if (!(obj instanceof AccountKey)) {
        return false;
      }
      final AccountKey other = (AccountKey) obj;
      return Objects.equals(mAccountName, other.mAccountName)
          && Objects.equals(mAccountType, other.mAccountType)
          && Objects.equals(mDataSet, other.mDataSet);
    }
  }
}
